Skip to main content
  1. Writing/

Testing Your Network Speed On macOS From The Terminal

·1000 words

Starting with macOS Monterrey (released all the way back in 2021) a nifty little utility is bundled with the operating system - networkquality. For whatever reason, Apple doesn’t really document it anywhere outside a lone support article. This allows any macOS user to conveniently run speed tests right in the comfort of their Terminal by executing the following command:

networkquality

After running a short test, you’ll get a human-readable version of the results, something like this:

==== SUMMARY ====
Uplink capacity: 26.718 Mbps
Downlink capacity: 194.394 Mbps
Responsiveness: Low (151 RPM)
Idle Latency: 21.917 milliseconds
Running the networkquality utility in macOS Terminal.
Running the networkquality utility in macOS Terminal.

This is great and all, but if you’d want to automate speed tests and log the data in, say, a SQLite database locally, then parsing the output above would be a hassle. Maybe not so much hassle with regular expressions, but a hassle nonetheless. What if you got the same results in a computer-readable format, like JSON? Run the following:

networkquality -c

You should get an output similar to this:

{
  "base_rtt" : 24.5,
  "dl_flows" : 16,
  "dl_throughput" : 216102304,
  "end_date" : "10/12/23, 2:15:48 PM",
  "il_h2_req_resp" : [
    23,
    14,
    17,
    21,
    19,
    21,
    15,
    25
  ],
  "il_tcp_handshake_443" : [
    13,
    18,
    19,
    19,
    18,
    18,
    20,
    16
  ],
  "il_tls_handshake" : [
    26,
    29,
    31,
    35,
    37,
    40,
    45,
    49
  ],
  "interface_name" : "en0",
  "lud_foreign_h2_req_resp" : [
    61,
    57,
    26,
    48,
    57,
    113,
    [...MORE DATA HERE...]
  ],
  "lud_foreign_tcp_handshake_443" : [
    27,
    35,
    37,
    48,
    48,
    47,
    [...MORE DATA HERE...]
  ],
  "lud_foreign_tls_handshake" : [
    32,
    50,
    89,
    83,
    58,
    100,
    [...MORE DATA HERE...]
  ],
  "lud_self_h2_req_resp" : [
    107,
    68,
    112,
    77,
    79,
    148,
    [...MORE DATA HERE...]
  ],
  "os_version" : "Version 13.5.1 (Build 22G90)",
  "responsiveness" : 69,
  "start_date" : "10/12/23, 2:15:36 PM",
  "ul_flows" : 8,
  "ul_throughput" : 13173327
}

The field values are posted here for reference since not everyone wants to run man networkQuality (capitalization is important) and read docs in the terminal:

JSON value Description
base_rtt The calculated idle latency of the test run (in milliseconds).
dl_flows Number of download flows initiated.
dl_throughput The measured downlink throughput (in bytes per second).
end_date Time when test run was completed (in local time).
il_h2_req_resp The idle-latency Request/Response times for HTTP/2 (in milliseconds).
il_tcp_handshake_443 The idle-latency TCP-handshake times (in milliseconds).
il_tls_handshake The idle-latency TLS-handshake times (in milliseconds).
interface_name Interface name in which the test ran against.
lud_foreign_h2_req_resp Combined upload/download latency-under-load request/response times for HTTP/2 (in milliseconds). Only available when -s is not specified.
lud_foreign_tcp_handshake_443 Combined upload/download latency-under-load for for TCP-handshake times (in milliseconds). Only available when -s is not specified.
lud_foreign_tls_handshake Combined foreign upload/download latency-under-load for for TLS-handshake times (in milliseconds). Only available when -s is not specified.
lud_self_h2_req_resp Combined self upload/download latency-under-load request/response times for HTTP/2 (in milliseconds). Only available when -s is not specified.
os_version The version of the OS the test was run on.
responsiveness The responsiveness score (in RPM). The combined value if -c is not specified.
start_date Time when test run was started (in local time).
ul_flows Number of upload flows created.
ul_throughput The measured uplink throughput (in bytes per second).

What’s great about the data in the JSON blob is that it comes “clean” - that is, you get an array of integers that you can easily pass to any chart visualization tooling, like the one I am using in this blog, and have it rendered right away:

On my macOS boxes, I run this via a cron to take snapshots and store them in a local SQLite database. I created a new database with the following command:

sqlite3 test.db "CREATE TABLE SpeedMetadata ( ResponseBody TEXT, BaseRtt Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.base_rtt')) VIRTUAL, DLFlows Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.dl_flows')) VIRTUAL, DLThroughput Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.dl_throughput')) VIRTUAL, EndDate DATETIME GENERATED ALWAYS AS (json_extract(ResponseBody, '$.end_date')) VIRTUAL, ILH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_h2_req_resp')) VIRTUAL, ILTCPHandshake443 Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_tcp_handshake_443')) VIRTUAL, ILTLSHandshake Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_tls_handshake')) VIRTUAL, InterfaceName Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.interface_name')) VIRTUAL, LUDForeighH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_h2_req_resp')) VIRTUAL, LUDForeignTCPHandshake443 Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_tcp_handshake_443')) VIRTUAL, LUDForeignTLSHandshake Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_tls_handshake')) VIRTUAL, LUDSelfH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_self_h2_req_resp')) VIRTUAL, OSVersion Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.os_version')) VIRTUAL, Responsiveness Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.responsiveness')) VIRTUAL, StartDate DATETIME GENERATED ALWAYS AS (json_extract(ResponseBody, '$.start_date')) VIRTUAL, ULFlows Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.ul_flows')) VIRTUAL, ULThroughput Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.ul_throughput')) VIRTUAL );"

This might seem long and scary, but the “beautified” SQL query is pretty simple:

CREATE TABLE SpeedMetadata (
  ResponseBody TEXT,
  BaseRtt Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.base_rtt')) VIRTUAL,
  DLFlows Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.dl_flows')) VIRTUAL,
  DLThroughput Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.dl_throughput')) VIRTUAL,
  EndDate DATETIME GENERATED ALWAYS AS (json_extract(ResponseBody, '$.end_date')) VIRTUAL,
  ILH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_h2_req_resp')) VIRTUAL,
  ILTCPHandshake443 Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_tcp_handshake_443')) VIRTUAL,
  ILTLSHandshake Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.il_tls_handshake')) VIRTUAL,
  InterfaceName Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.interface_name')) VIRTUAL,
  LUDForeighH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_h2_req_resp')) VIRTUAL,
  LUDForeignTCPHandshake443 Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_tcp_handshake_443')) VIRTUAL,
  LUDForeignTLSHandshake Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_foreign_tls_handshake')) VIRTUAL,
  LUDSelfH2ReqResp Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.lud_self_h2_req_resp')) VIRTUAL,
  OSVersion Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.os_version')) VIRTUAL,
  Responsiveness Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.responsiveness')) VIRTUAL,
  StartDate DATETIME GENERATED ALWAYS AS (json_extract(ResponseBody, '$.start_date')) VIRTUAL,
  ULFlows Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.ul_flows')) VIRTUAL,
  ULThroughput Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.ul_throughput')) VIRTUAL
);

It creates the SpeedMetadata table, where ResponseBody is the column where I pass the JSON response from networkquality, and all other columns are automatically computed from the JSON properties in the response (yes, SQLite is that flexible).

Insertion can be done with another command:

sqlite3 test.db "insert into SpeedMetadata (ResponseBody) values ('$(networkquality -c)')"

And if we use something like DB Browser for SQLite, we get to see the results logged in the database:

Results of the networkquality command in DB Browser for SQLite.
Results of the networkquality command in DB Browser for SQLite.

Neat little way to actually validate that my ISP’s advertised speed is almost never the speed I actually get and pay for.